feat: add local MCP context tools#120
Conversation
PR SummaryMedium Risk Overview Updates diagnostics redaction to make Reviewed by Cursor Bugbot for commit 519f2f1. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Thrown errors produce no JSON-RPC response, clients hang
- AgentdMCP request handling now converts parse, invalid-parameter, and runtime failures into JSON-RPC error responses instead of dropping them on stderr.
- ✅ Fixed: Duplicated
redactEndpointacross two files- Endpoint redaction now lives in one shared helper used by both diagnostics and MCP snapshot code.
Preview (f19541561f)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -382,6 +382,13 @@
encrypted `.agentdbatch` files remain unreadable without the configured local
batch key, and raw OCR is not copied into the summary layer.
+For local agent context, run `agentd mcp` as a stdio MCP server. It exposes
+three local tools: `agentd_device_snapshot` for redacted device/permission and
+privacy-policy status, `agentd_activity_recent` for sanitized recent activity
+from JSON batches, and `agentd_collect_diagnostics` for writing the same
+Chronicle-style activity artifacts to a caller-provided local directory. The
+MCP surface never returns raw frames or encrypted fallback batches.
+
`scripts/mock_chronicle.py` provides a strict local mock Chronicle and Secret
Broker harness. CI validates the golden fixtures in `Tests/Fixtures/chronicle`
so request-shape drift is explicit until generated `chronicle.v1` Swift types
@@ -404,6 +411,7 @@
Sources/agentd/
main.swift # NSApplication + AppController boot
ChronicleControl.swift # RegisterDevice/Heartbeat + policy response client
+ AgentdMCP.swift # Local stdio MCP server for redacted device context
ActivitySummary.swift # Sanitized local activity summaries/resources
Diagnostics.swift # Redacted local report generation
PauseState.swift # Manual/scheduled/policy pause precedence
diff --git a/Sources/agentd/AgentdMCP.swift b/Sources/agentd/AgentdMCP.swift
new file mode 100644
--- /dev/null
+++ b/Sources/agentd/AgentdMCP.swift
@@ -1,0 +1,364 @@
+// SPDX-License-Identifier: BUSL-1.1
+
+import Foundation
+
+struct AgentdMCPPermissionStatus: Codable, Equatable, Sendable {
+ let accessibilityTrusted: Bool
+ let screenCaptureTrusted: Bool
+ let menuSummary: String
+}
+
+struct AgentdMCPPrivacyStatus: Codable, Equatable, Sendable {
+ let allowedBundleCount: Int
+ let deniedBundleCount: Int
+ let deniedPathPrefixCount: Int
+ let pauseTitlePatternCount: Int
+ let captureAllDisplays: Bool
+ let selectedDisplayIds: [UInt32]
+}
+
+struct AgentdMCPLocalBatchStats: Codable, Equatable, Sendable {
+ let fileCount: Int
+ let bytes: Int64
+
+ init(fileCount: Int, bytes: Int64) {
+ self.fileCount = fileCount
+ self.bytes = bytes
+ }
+
+ init(_ stats: LocalBatchStats) {
+ self.fileCount = stats.fileCount
+ self.bytes = stats.bytes
+ }
+}
+
+struct AgentdMCPDeviceSnapshot: Codable, Equatable, Sendable {
+ let generatedAt: Date
+ let appVersion: String
+ let deviceId: String
+ let organizationId: String
+ let mode: String
+ let endpoint: String
+ let permissions: AgentdMCPPermissionStatus
+ let localBatchStats: AgentdMCPLocalBatchStats
+ let privacy: AgentdMCPPrivacyStatus
+}
+
+struct AgentdMCPDiagnosticsResult: Codable, Equatable, Sendable {
+ let instructionsPath: String
+ let resourcePaths: [String]
+}
+
+protocol AgentdMCPRuntime {
+ func deviceSnapshot() async throws -> AgentdMCPDeviceSnapshot
+ func activityRecent(options: ActivityOptions) async throws -> ActivitySummary
+ func collectDiagnostics(options: ActivityOptions, outputDirectory: URL) async throws
+ -> AgentdMCPDiagnosticsResult
+}
+
+struct SystemAgentdMCPRuntime: AgentdMCPRuntime {
+ func deviceSnapshot() async throws -> AgentdMCPDeviceSnapshot {
+ let config = ConfigStore.load()
+ let permissions = await MainActor.run {
+ PermissionSnapshot.current(promptForAccessibility: false)
+ }
+ let submitter = try Submitter(
+ endpoint: config.endpoint,
+ localOnly: true,
+ authMode: .none,
+ maxBatchBytes: config.maxBatchBytes,
+ maxBatchAgeDays: config.maxBatchAgeDays,
+ deviceId: config.deviceId,
+ encryptLocalBatches: config.encryptLocalBatches
+ )
+ let batchStats = await submitter.localBatchStats()
+
+ return AgentdMCPDeviceSnapshot(
+ generatedAt: Date(),
+ appVersion: Bundle.main.appVersion,
+ deviceId: config.deviceId,
+ organizationId: config.organizationId,
+ mode: config.localOnly ? "local-only" : "managed",
+ endpoint: EndpointRedaction.redact(config.endpoint),
+ permissions: AgentdMCPPermissionStatus(
+ accessibilityTrusted: permissions.accessibilityTrusted,
+ screenCaptureTrusted: permissions.screenCaptureTrusted,
+ menuSummary: permissions.menuSummary
+ ),
+ localBatchStats: AgentdMCPLocalBatchStats(batchStats),
+ privacy: AgentdMCPPrivacyStatus(
+ allowedBundleCount: config.allowedBundleIds.count,
+ deniedBundleCount: config.deniedBundleIds.count,
+ deniedPathPrefixCount: config.deniedPathPrefixes.count,
+ pauseTitlePatternCount: config.pauseWindowTitlePatterns.count,
+ captureAllDisplays: config.captureAllDisplays,
+ selectedDisplayIds: config.selectedDisplayIds
+ )
+ )
+ }
+
+ func activityRecent(options: ActivityOptions) async throws -> ActivitySummary {
+ try await ActivitySummary.run(options: options)
+ }
+
+ func collectDiagnostics(options: ActivityOptions, outputDirectory: URL) async throws
+ -> AgentdMCPDiagnosticsResult
+ {
+ let summary = try await ActivitySummary.run(options: options)
+ let resource = try ActivitySummaryArtifacts.write(summary, root: outputDirectory)
+ return AgentdMCPDiagnosticsResult(
+ instructionsPath: outputDirectory.appendingPathComponent("instructions.md").path,
+ resourcePaths: [resource.path]
+ )
+ }
+
+}
+
+struct AgentdMCPServer {
+ private let runtime: AgentdMCPRuntime
+
+ init(runtime: AgentdMCPRuntime = SystemAgentdMCPRuntime()) {
+ self.runtime = runtime
+ }
+
+ func handle(_ data: Data) async -> Data {
+ do {
+ let request = try AgentdMCPRequest(data: data)
+ do {
+ return try await handleRequest(request)
+ } catch {
+ let mcpError = Self.jsonRPCError(for: error)
+ return safeErrorResponse(id: request.id, code: mcpError.code, message: mcpError.message)
+ }
+ } catch let error as AgentdMCPError {
+ let mcpError = Self.jsonRPCError(for: error)
+ return safeErrorResponse(id: nil, code: mcpError.code, message: mcpError.message)
+ } catch {
+ return safeErrorResponse(id: nil, code: -32700, message: "parse error")
+ }
+ }
+
+ private func handleRequest(_ request: AgentdMCPRequest) async throws -> Data {
+ switch request.method {
+ case "initialize":
+ return try response(
+ id: request.id,
+ result: [
+ "protocolVersion": "2025-06-18",
+ "capabilities": ["tools": ["listChanged": false]],
+ "serverInfo": ["name": "agentd-local", "version": Bundle.main.appVersion],
+ ])
+ case "notifications/initialized":
+ return Data()
+ case "tools/list":
+ return try response(id: request.id, result: ["tools": Self.toolCatalog()])
+ case "tools/call":
+ return try await callTool(request)
+ default:
+ return try errorResponse(id: request.id, code: -32601, message: "method not found")
+ }
+ }
+
+ private func callTool(_ request: AgentdMCPRequest) async throws -> Data {
+ guard let params = request.params,
+ let name = params["name"] as? String
+ else {
+ return try errorResponse(id: request.id, code: -32602, message: "tools/call requires name")
+ }
+ let arguments = params["arguments"] as? [String: Any] ?? [:]
+
+ switch name {
+ case "agentd_device_snapshot":
+ return try await toolResponse(id: request.id, value: runtime.deviceSnapshot())
+ case "agentd_activity_recent":
+ let options = try activityOptions(from: arguments)
+ return try await toolResponse(id: request.id, value: runtime.activityRecent(options: options))
+ case "agentd_collect_diagnostics":
+ let options = try activityOptions(from: arguments)
+ let outputDirectory = outputDirectory(from: arguments)
+ return try await toolResponse(
+ id: request.id,
+ value: runtime.collectDiagnostics(options: options, outputDirectory: outputDirectory)
+ )
+ default:
+ return try errorResponse(id: request.id, code: -32602, message: "unknown tool '\(name)'")
+ }
+ }
+
+ private func toolResponse<T: Encodable>(id: Any?, value: T) async throws -> Data {
+ let text = try Self.jsonString(value)
+ return try response(
+ id: id,
+ result: [
+ "content": [
+ [
+ "type": "text",
+ "mimeType": "application/json",
+ "text": text,
+ ]
+ ],
+ "isError": false,
+ ])
+ }
+
+ private func activityOptions(from arguments: [String: Any]) throws -> ActivityOptions {
+ var raw: [String] = []
+ if let window = arguments["window"] as? String {
+ raw += ["--window", window]
+ }
+ if let since = arguments["since"] {
+ raw += ["--since", String(describing: since)]
+ }
+ if let batchDirectory = arguments["batch_dir"] as? String {
+ raw += ["--batch-dir", batchDirectory]
+ }
+ return try ActivityOptions.parse(raw)
+ }
+
+ private func outputDirectory(from arguments: [String: Any]) -> URL {
+ if let path = arguments["out_dir"] as? String, !path.isEmpty {
+ return URL(fileURLWithPath: path, isDirectory: true)
+ }
+ return FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent(".evalops/agentd/mcp-diagnostics", isDirectory: true)
+ }
+
+ private func response(id: Any?, result: [String: Any]) throws -> Data {
+ try Self.jsonData([
+ "jsonrpc": "2.0",
+ "id": id ?? NSNull(),
+ "result": result,
+ ])
+ }
+
+ private func errorResponse(id: Any?, code: Int, message: String) throws -> Data {
+ try Self.jsonData([
+ "jsonrpc": "2.0",
+ "id": id ?? NSNull(),
+ "error": ["code": code, "message": message],
+ ])
+ }
+
+ private func safeErrorResponse(id: Any?, code: Int, message: String) -> Data {
+ (try? errorResponse(id: id, code: code, message: message))
+ ?? (Data(#"{"error":{"code":-32603,"message":"internal error"},"id":null,"jsonrpc":"2.0"}"#.utf8)
+ + Data([0x0A]))
+ }
+
+ private static func toolCatalog() -> [[String: Any]] {
+ [
+ [
+ "name": "agentd_device_snapshot",
+ "description":
+ "Return a redacted local device snapshot including agentd mode, permissions, privacy policy counts, and queued local batch stats.",
+ "inputSchema": ["type": "object", "additionalProperties": false, "properties": [:]],
+ "annotations": ["title": "Device Snapshot", "readOnlyHint": true],
+ ],
+ [
+ "name": "agentd_activity_recent",
+ "description":
+ "Summarize recent local agentd activity from persisted redacted batch JSON without returning raw frames.",
+ "inputSchema": [
+ "type": "object",
+ "additionalProperties": false,
+ "properties": [
+ "window": ["type": "string", "enum": ["10m", "6h", "24h"]],
+ "since": ["type": "number"],
+ "batch_dir": ["type": "string"],
+ ],
+ ],
+ "annotations": ["title": "Recent Activity", "readOnlyHint": true],
+ ],
+ [
+ "name": "agentd_collect_diagnostics",
+ "description":
+ "Write Chronicle-style local activity summary artifacts for support/debugging and return their paths.",
+ "inputSchema": [
+ "type": "object",
+ "required": ["out_dir"],
+ "additionalProperties": false,
+ "properties": [
+ "window": ["type": "string", "enum": ["10m", "6h", "24h"]],
+ "since": ["type": "number"],
+ "batch_dir": ["type": "string"],
+ "out_dir": ["type": "string"],
+ ],
+ ],
+ "annotations": ["title": "Collect Diagnostics", "readOnlyHint": false],
+ ],
+ ]
+ }
+
+ private static func jsonString<T: Encodable>(_ value: T) throws -> String {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ return String(decoding: try encoder.encode(value), as: UTF8.self)
+ }
+
+ private static func jsonData(_ object: [String: Any]) throws -> Data {
+ try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
+ + Data([0x0A])
+ }
+
+ private static func jsonRPCError(for error: Error) -> (code: Int, message: String) {
+ switch error {
+ case let error as AgentdMCPError:
+ return (error.code, error.localizedDescription)
+ case let error as DiagnosticCLIError:
+ return (-32602, error.localizedDescription)
+ default:
+ return (-32603, error.localizedDescription)
+ }
+ }
+}
+
+struct AgentdMCPRequest {
+ let id: Any?
+ let method: String
+ let params: [String: Any]?
+
+ init(data: Data) throws {
+ let object = try JSONSerialization.jsonObject(with: data)
+ guard let root = object as? [String: Any],
+ let method = root["method"] as? String
+ else {
+ throw AgentdMCPError.invalidRequest
+ }
+ self.id = root["id"]
+ self.method = method
+ self.params = root["params"] as? [String: Any]
+ }
+}
+
+enum AgentdMCPError: Error, LocalizedError {
+ case invalidRequest
+
+ var code: Int {
+ switch self {
+ case .invalidRequest:
+ return -32600
+ }
+ }
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidRequest:
+ return "invalid MCP JSON-RPC request"
+ }
+ }
+}
+
+enum AgentdMCPStdio {
+ static func run(server: AgentdMCPServer = AgentdMCPServer()) async -> Int32 {
+ while let line = readLine(strippingNewline: true) {
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { continue }
+ let response = await server.handle(Data(trimmed.utf8))
+ if !response.isEmpty {
+ FileHandle.standardOutput.write(response)
+ }
+ }
+ return 0
+ }
+}
diff --git a/Sources/agentd/DiagnosticCLI.swift b/Sources/agentd/DiagnosticCLI.swift
--- a/Sources/agentd/DiagnosticCLI.swift
+++ b/Sources/agentd/DiagnosticCLI.swift
@@ -109,7 +109,7 @@
enum DiagnosticCLI {
static let handledCommands = [
"list-displays", "capture-once", "capture-worker-once", "capture-worker-stream", "selftest",
- "activity", "help", "--help", "-h",
+ "activity", "mcp", "help", "--help", "-h",
]
static func shouldHandle(_ arguments: [String]) -> Bool {
@@ -137,6 +137,8 @@
case .selftest:
let payload = await SelftestDiagnostics.run()
try writeJSON(payload, to: nil)
+ case .mcp:
+ return await AgentdMCPStdio.run()
case .activity(let options):
let payload = try await ActivitySummary.run(options: options)
if let summaryRoot = options.summaryRoot {
@@ -199,12 +201,14 @@
agentd list-displays
agentd capture-once [--display-id ID] [--no-ocr] [--out PATH]
agentd activity [--since HOURS] [--window 10m|6h|24h] [--format json|markdown] [--batch-dir PATH] [--write-summaries PATH]
+ agentd mcp
agentd selftest
Diagnostic commands emit redacted JSON and never start the menu-bar app.
capture-once uses the normal privacy filters, SecretScrubber, and OCR pipeline.
activity summarizes locally persisted JSON batches without reading encrypted batch files.
--write-summaries writes Chronicle-style instructions.md and resources/*.md locally.
+ mcp starts a local JSON-RPC stdio MCP server with redacted device/context tools.
"""
}
@@ -217,6 +221,7 @@
case captureWorkerStream(CaptureStreamOptions)
case selftest
case activity(ActivityOptions)
+ case mcp
static func parse(_ arguments: [String]) throws -> DiagnosticCommand {
guard let command = arguments.first else { return .help }
@@ -236,6 +241,9 @@
case "selftest":
guard tail.isEmpty else { throw DiagnosticCLIError.usage("selftest takes no flags") }
return .selftest
+ case "mcp":
+ guard tail.isEmpty else { throw DiagnosticCLIError.usage("mcp takes no flags") }
+ return .mcp
case "activity":
return .activity(try ActivityOptions.parse(tail))
default:
diff --git a/Sources/agentd/Diagnostics.swift b/Sources/agentd/Diagnostics.swift
--- a/Sources/agentd/Diagnostics.swift
+++ b/Sources/agentd/Diagnostics.swift
@@ -34,7 +34,7 @@
lines.append("- Screen capture preflight: \(snapshot.permissions.screenCaptureTrusted)")
lines.append("- Mode: \(snapshot.config.localOnly ? "local-only" : "managed")")
lines.append("- Secret Broker: \(snapshot.config.secretBroker == nil ? "disabled" : "enabled")")
- lines.append("- Endpoint: \(redactEndpoint(snapshot.config.endpoint))")
+ lines.append("- Endpoint: \(EndpointRedaction.redact(snapshot.config.endpoint))")
lines.append("- Policy version: \(snapshot.policyVersion ?? "none")")
lines.append("- Policy source: \(redact(snapshot.policySource ?? "none"))")
lines.append("- Last control error: \(redact(snapshot.controlError ?? "none"))")
@@ -185,14 +185,6 @@
return redact(value)
}
- private static func redactEndpoint(_ endpoint: URL) -> String {
- var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
- components?.query = nil
- components?.user = nil
- components?.password = nil
- return components?.url?.absoluteString ?? "[redacted]"
- }
-
private static func iso(_ date: Date) -> String {
ISO8601DateFormatter().string(from: date)
}
diff --git a/Sources/agentd/EndpointRedaction.swift b/Sources/agentd/EndpointRedaction.swift
new file mode 100644
--- /dev/null
+++ b/Sources/agentd/EndpointRedaction.swift
@@ -1,0 +1,13 @@
+// SPDX-License-Identifier: BUSL-1.1
+
+import Foundation
+
+enum EndpointRedaction {
+ static func redact(_ endpoint: URL) -> String {
+ var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
+ components?.query = nil
+ components?.user = nil
+ components?.password = nil
+ return components?.url?.absoluteString ?? "[redacted]"
+ }
+}
diff --git a/Tests/agentdTests/AgentdMCPTestSupport.swift b/Tests/agentdTests/AgentdMCPTestSupport.swift
new file mode 100644
--- /dev/null
+++ b/Tests/agentdTests/AgentdMCPTestSupport.swift
@@ -1,0 +1,143 @@
+// SPDX-License-Identifier: BUSL-1.1
+
+import Foundation
+import XCTest
+
+@testable import agentd
+
+final class AgentdMCPRuntimeStub: AgentdMCPRuntime {
+ var deviceSnapshot = AgentdMCPDeviceSnapshot(
+ generatedAt: Date(timeIntervalSince1970: 0),
+ appVersion: "test",
+ deviceId: "device_test",
+ organizationId: "org_test",
+ mode: "local-only",
+ endpoint: "http://127.0.0.1:8787/chronicle.v1.ChronicleService/SubmitBatch",
+ permissions: AgentdMCPPermissionStatus(
+ accessibilityTrusted: true,
+ screenCaptureTrusted: true,
+ menuSummary: "Ready"
+ ),
+ localBatchStats: AgentdMCPLocalBatchStats(fileCount: 0, bytes: 0),
+ privacy: AgentdMCPPrivacyStatus(
+ allowedBundleCount: 0,
+ deniedBundleCount: 0,
+ deniedPathPrefixCount: 0,
+ pauseTitlePatternCount: 0,
+ captureAllDisplays: true,
+ selectedDisplayIds: []
+ )
+ )
+ var activitySummary = ActivitySummaryTests.summary(batchDirectory: URL(fileURLWithPath: "/tmp"))
+ var diagnosticsResult = AgentdMCPDiagnosticsResult(
+ instructionsPath: "/tmp/instructions.md",
+ resourcePaths: ["/tmp/resources/activity.md"]
+ )
+ var deviceSnapshotError: Error?
+ var activityError: Error?
+ var diagnosticsError: Error?
+ private(set) var requestedActivity: ActivityOptions?
+ private(set) var requestedDiagnostics: ActivityOptions?
+ private(set) var requestedDiagnosticsOutDir: URL?
+
+ func deviceSnapshot() async throws -> AgentdMCPDeviceSnapshot {
+ if let deviceSnapshotError {
+ throw deviceSnapshotError
+ }
+ deviceSnapshot
+ }
+
+ func activityRecent(options: ActivityOptions) async throws -> ActivitySummary {
+ if let activityError {
+ throw activityError
+ }
+ requestedActivity = options
+ return activitySummary.replacing(
+ batchDirectory: options.batchDirectory.path,
+ windowLabel: options.windowLabel
+ )
+ }
+
+ func collectDiagnostics(options: ActivityOptions, outputDirectory: URL) async throws
+ -> AgentdMCPDiagnosticsResult
+ {
+ if let diagnosticsError {
+ throw diagnosticsError
+ }
+ requestedDiagnostics = options
+ requestedDiagnosticsOutDir = outputDirectory
+ return diagnosticsResult
+ }
+}
+
+func jsonData(_ object: [String: Any]) throws -> Data {
+ try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
+}
+
+func jsonObject(_ data: Data) throws -> [String: Any] {
+ let decoded = try JSONSerialization.jsonObject(with: data)
+ return try XCTUnwrap(decoded as? [String: Any])
+}
+
+func mcpText(_ data: Data) throws -> String {
+ let root = try jsonObject(data)
+ let result = try XCTUnwrap(root["result"] as? [String: Any])
+ let content = try XCTUnwrap(result["content"] as? [[String: Any]])
+ return try XCTUnwrap(content.first?["text"] as? String)
+}
+
+struct AgentdMCPStubError: LocalizedError {
+ let message: String
+
+ var errorDescription: String? { message }
+}
+
+extension ActivitySummaryTests {
+ static func summary(
+ batchDirectory: URL,
+ windowLabel: String = "24h",
+ windows: [ActivityWindowSummary] = []
+ ) -> ActivitySummary {
+ ActivitySummary(
+ generatedAt: Date(timeIntervalSince1970: 1_000),
+ since: Date(timeIntervalSince1970: 0),
+ until: Date(timeIntervalSince1970: 1_000),
+ staleAfter: Date(timeIntervalSince1970: 1_600),
+ windowLabel: windowLabel,
+ batchDirectory: batchDirectory.path,
+ batchCount: 0,
+ nonemptyBatchCount: 0,
+ frameCount: 0,
+ sourceBatchIds: [],
+ displayIds: [],
+ droppedCounts: DropCounts(secret: 0, duplicate: 0, deniedApp: 0, deniedPath: 0),
+ droppedReasonCounts: [:],
+ apps: [],
+ windows: windows,
+ artifacts: []
+ )
+ }
+}
+
+extension ActivitySummary {
+ func replacing(batchDirectory: String, windowLabel: String) -> ActivitySummary {
+ ActivitySummary(
+ generatedAt: generatedAt,
+ since: since,
+ until: until,
+ staleAfter: staleAfter,
+ windowLabel: windowLabel,
+ batchDirectory: batchDirectory,
+ batchCount: batchCount,
+ nonemptyBatchCount: nonemptyBatchCount,
+ frameCount: frameCount,
+ sourceBatchIds: sourceBatchIds,
+ displayIds: displayIds,
+ droppedCounts: droppedCounts,
+ droppedReasonCounts: droppedReasonCounts,
+ apps: apps,
+ windows: windows,
+ artifacts: artifacts
+ )
+ }
+}
diff --git a/Tests/agentdTests/DiagnosticCLITests.swift b/Tests/agentdTests/DiagnosticCLITests.swift
--- a/Tests/agentdTests/DiagnosticCLITests.swift
+++ b/Tests/agentdTests/DiagnosticCLITests.swift
@@ -14,9 +14,244 @@
XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "capture-worker-stream"]))
XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "selftest"]))
XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "activity"]))
+ XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "mcp"]))
XCTAssertFalse(DiagnosticCLI.shouldHandle(["agentd", "--local-only"]))
}
+ func testMcpInitializeAndToolsListExposeLocalContextTools() async throws {
+ let runtime = AgentdMCPRuntimeStub()
+ let server = AgentdMCPServer(runtime: runtime)
+
+ let initialize = await server.handle(
+ try jsonData(
+ [
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": [
+ "protocolVersion": "2025-06-18",
+ "capabilities": [:],
+ "clientInfo": ["name": "codex-test", "version": "dev"],
+ ],
+ ]))
+ let initializeRoot = try jsonObject(initialize)
+
+ XCTAssertEqual(initializeRoot["jsonrpc"] as? String, "2.0")
+ XCTAssertEqual(initializeRoot["id"] as? Int, 1)
+ let initializeResult = try XCTUnwrap(initializeRoot["result"] as? [String: Any])
+ XCTAssertEqual(initializeResult["protocolVersion"] as? String, "2025-06-18")
+
+ let tools = await server.handle(
+ try jsonData(["jsonrpc": "2.0", "id": "tools", "method": "tools/list"]))
+ let toolsRoot = try jsonObject(tools)
+ let toolsResult = try XCTUnwrap(toolsRoot["result"] as? [String: Any])
+ let toolList = try XCTUnwrap(toolsResult["tools"] as? [[String: Any]])
+ let names = Set(toolList.compactMap { $0["name"] as? String })
+
+ XCTAssertEqual(
+ names,
+ ["agentd_device_snapshot", "agentd_activity_recent", "agentd_collect_diagnostics"]
+ )
+ let annotationsByName = Dictionary(
+ uniqueKeysWithValues: try toolList.map { tool in
+ (
+ try XCTUnwrap(tool["name"] as? String),
+ try XCTUnwrap(tool["annotations"] as? [String: Any])
+ )
+ }
+ )
+ XCTAssertEqual(annotationsByName["agentd_device_snapshot"]?["readOnlyHint"] as? Bool, true)
+ XCTAssertEqual(annotationsByName["agentd_activity_recent"]?["readOnlyHint"] as? Bool, true)
+ XCTAssertEqual(annotationsByName["agentd_collect_diagnostics"]?["readOnlyHint"] as? Bool, false)
+ }
+
+ func testMcpActivityRecentReturnsRedactedActivitySummary() async throws {
+ let root = try temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: root) }
+ let runtime = AgentdMCPRuntimeStub()
+ runtime.activitySummary = ActivitySummaryTests.summary(
+ batchDirectory: root,
+ windows: [
+ ActivityWindowSummary(
+ appName: "Google Chrome",
+ bundleId: "com.google.Chrome",
+ windowTitle: "Review EvalOps",
+ documentPath: "https://github.com/evalops/platform/pull/123?code=REDACTED&safe=1",
+ frameCount: 3,
+ firstSeenAt: Date(timeIntervalSince1970: 100),
+ lastSeenAt: Date(timeIntervalSince1970: 120)
+ )
+ ]
+ )
+ let server = AgentdMCPServer(runtime: runtime)
+
+ let response = await server.handle(
+ try jsonData([
+ "jsonrpc": "2.0",
+ "id": "activity",
+ "method": "tools/call",
+ "params": [
+ "name": "agentd_activity_recent",
+ "arguments": ["window": "6h", "batch_dir": root.path],
+ ],
+ ]))
+ let text = try mcpText(response)
+ let decoded = try jsonObject(Data(text.utf8))
+
+ XCTAssertEqual(decoded["windowLabel"] as? String, "6h")
+ XCTAssertEqual(decoded["batchDirectory"] as? String, root.path)
+ let windows = try XCTUnwrap(decoded["windows"] as? [[String: Any]])
+ XCTAssertEqual(
+ windows.first?["documentPath"] as? String,
+ "https://github.com/evalops/platform/pull/123?code=REDACTED&safe=1"
+ )
+ XCTAssertEqual(runtime.requestedActivity?.windowLabel, "6h")
+ XCTAssertEqual(runtime.requestedActivity?.batchDirectory.path, root.path)
+ }
+
+ func testMcpCollectDiagnosticsWritesActivityArtifactsAndReturnsPaths() async throws {
+ let root = try temporaryDirectory()
+ let out = try temporaryDirectory()
+ defer {
+ try? FileManager.default.removeItem(at: root)
+ try? FileManager.default.removeItem(at: out)
+ }
+ let runtime = AgentdMCPRuntimeStub()
+ runtime.diagnosticsResult = AgentdMCPDiagnosticsResult(
+ instructionsPath: out.appendingPathComponent("instructions.md").path,
+ resourcePaths: [out.appendingPathComponent("resources/activity-24h.md").path]
+ )
+ let server = AgentdMCPServer(runtime: runtime)
+
+ let response = await server.handle(
+ try jsonData([
+ "jsonrpc": "2.0",
+ "id": "diag",
+ "method": "tools/call",
+ "params": [
+ "name": "agentd_collect_diagnostics",
+ "arguments": ["batch_dir": root.path, "out_dir": out.path, "window": "24h"],
+ ],
+ ]))
+ let decoded = try jsonObject(Data(try mcpText(response).utf8))
+
+ XCTAssertEqual(
+ decoded["instructionsPath"] as? String,
+ out.appendingPathComponent("instructions.md").path
+ )
+ XCTAssertEqual(
+ decoded["resourcePaths"] as? [String],
+ [out.appendingPathComponent("resources/activity-24h.md").path]
+ )
+ XCTAssertEqual(runtime.requestedDiagnostics?.batchDirectory.path, root.path)
+ XCTAssertEqual(runtime.requestedDiagnosticsOutDir?.path, out.path)
+ }
+
+ func testMcpDeviceSnapshotReportsRedactedLocalStatus() async throws {
+ let runtime = AgentdMCPRuntimeStub()
+ runtime.deviceSnapshot = AgentdMCPDeviceSnapshot(
+ generatedAt: Date(timeIntervalSince1970: 0),
+ appVersion: "0.2.0",
+ deviceId: "device_1",
+ organizationId: "evalops",
+ mode: "managed",
+ endpoint: "https://chronicle.evalops.dev/chronicle.v1.ChronicleService/SubmitBatch",
+ permissions: AgentdMCPPermissionStatus(
+ accessibilityTrusted: true,
+ screenCaptureTrusted: false,
+ menuSummary: "Needs Screen Recording"
... diff truncated: showing 800 of 895 linesYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 5f0dc16. Configure here.

Summary
agentd mcp, a stdio JSON-RPC MCP server for local agent/device contextVerification
swift testxcrun swift-format lint --strict --recursive Sources Tests Package.swiftgit diff --checkprintf ... | .build/debug/agentd mcpsmoke test